Skip to main content

Check out Port for yourself ➜ 

Common jq use-cases

Port uses JQ to transform and map data in integration configs, self-service action backends, and dynamic policies. This page covers the patterns that most commonly cause issues: null returns, silent failures, and type errors.

Filtering arrays that contain empty strings

The built-in isNotEmpty operator returns true for [""], an array with one empty string, because the array itself is not empty. To exclude these, combine isNotEmpty with an explicit empty-string check in JQ.

Goal: Filter entities where entra_group_ids contains at least one real value (not just "").

Wrong: matches [""]:

{
"property": "entra_group_ids",
"operator": "isNotEmpty"
}

Correct: excludes [""]:

{
"combinator": "and",
"rules": [
{ "property": "entra_group_ids", "operator": "isNotEmpty" },
{
"property": "entra_group_ids",
"operator": "!=",
"value": { "jqQuery": "[\"\"]" }
}
]
}

If you are writing a JQ expression directly (for example in a mapping or backend body), use:

.entra_group_ids | map(select(. != "")) | length > 0

Parsing a JSON field value

Some integration API responses include a field whose value is a JSON-encoded string - a string that contains serialized JSON. Use fromjson to parse it before accessing keys.

YAML inputs in self-service actions

In self-service actions, inputs declared with type: "string" and format: "yaml" are not raw strings at runtime. Port validates and parses the submitted YAML, then delivers the resulting object to your backend. You can therefore access keys directly without any parsing step:

.inputs.raw_config.dataset

If you need the object to be forwarded correctly, make sure the invocation method has omitUserInputs: false (see omitUserInputs and omitPayload).

Use case: An API field metadata contains a JSON-encoded string such as "{\"dataset\": \"prod\"}" and you need to read the dataset key.

Input field: metadata - a plain string whose value is JSON.

Wrong: errors because JQ cannot index a string as an object:

.metadata.dataset

Correct: parses the JSON string first:

(.metadata | fromjson).dataset

Null-safe version (returns null instead of erroring when the field is missing or empty):

(.metadata // "{}" | fromjson).dataset

Mapping example (integration YAML):

- kind: workspace
port:
entity:
mappings:
properties:
dataset: '(.metadata | fromjson).dataset'
format: json in blueprint properties

If the source field is always a JSON-encoded string, you can also declare the blueprint property as type: "object" and use format: "json" in the mapping so Port parses it automatically. See Object property.

Array relations with missing entities

If you map a relation to an array and one identifier in that array does not exist as an entity in Port, the upsert of the parent entity fails with a validation error.

Problem mapping:

githubTeams: "[.__teams[].id | tostring]"

If team "3178082" does not exist in Port, the entire repository entity upsert fails.

Option A: exclude known missing IDs:

githubTeams: '([.__teams[].id | tostring] - ["3178082", "8446394"])'

Option B: filter out items with missing IDs (recommended):

githubTeams: "[.__teams[] | select(.id != null) | .id | tostring]"

Option C: filter to only IDs that match a known pattern:

githubTeams: '[.__teams[].id | tostring | select(test("^[0-9]+$"))]'
Relations may be empty

Filtering out missing IDs means those relations will be absent from the entity. The entity will still be created, and the relation will just be empty. This is usually the correct tradeoff.

omitUserInputs and omitPayload with the Port execution agent

When you run a self-service action through the Port execution agent (GitLab pipeline, GitHub workflow, or WEBHOOK with agent: true), Port forwards an event payload that your agent maps with invocations.json (see Control the payload). In the documented example payload, payload.properties can be an empty object until user inputs are included in the forwarded message.

Port's guides therefore often set omitUserInputs and omitPayload to false on the invocation method whenever the backend or agent mapping needs those fields (for example Trigger ServiceNow incident and Deploy S3 bucket using Crossplane).

Symptom: Your invocations.json filter references .payload.properties.some_input, but the condition never matches because payload.properties is {}.

Fix - set both flags to false on the invocation method (GitLab example):

{
"invocationMethod": {
"type": "GITLAB",
"projectName": "my-project",
"groupName": "my-group",
"defaultRef": "main",
"agent": true,
"omitUserInputs": false,
"omitPayload": false
}
}

GitHub workflow example:

{
"invocationMethod": {
"type": "GITHUB",
"org": "my-org",
"repo": "my-repo",
"workflow": "my-workflow.yml",
"omitUserInputs": false,
"omitPayload": false,
"reportWorkflowStatus": true
}
}

Filter expression after fix:

{
"enabled": ".payload.properties.target_platform == \"gcp\""
}
Check agent mapping errors first

If you see Could not find suitable mapping for the event in Port agent logs, confirm that invocations.json is loaded and that user inputs are present under .payload.properties (see the omitUserInputs / omitPayload settings above).

Reading a CODEOWNERS file

For GitHub Ocean, Port recommends includedFiles on the repository resource and mapping file text from .__includedFiles["<path>"], instead of the legacy file:// prefix (deprecated; see Enrich entities with file contents).

Recommended: repository kind with includedFiles:

- kind: repository
selector:
query: "true"
includedFiles:
- README.md
- CODEOWNERS
- .github/CODEOWNERS
port:
entity:
mappings:
properties:
readme: .__includedFiles["README.md"]
codeowners: (.__includedFiles["CODEOWNERS"] // .__includedFiles[".github/CODEOWNERS"])

includedFiles resolves paths on the repository default branch, stores each file under .__includedFiles, and uses null when a path is missing. Extend the list for the paths you rely on (for example .gitlab/CODEOWNERS on GitLab - see the GitLab integration docs).

Alternative: file kind with a glob path (one entity per matched file; useful for deep paths such as **/.github/CODEOWNERS):

- kind: file
selector:
query: "true"
files:
- path: "**/.github/CODEOWNERS"
organization: my-org
port:
entity:
mappings:
properties:
codeowners: .content

Parse the first owner from the default rule (*):

.codeowners
| split("\n")[]
| select(startswith("*"))
| split(" ")[1]

Return all owners as an array:

[
.codeowners
| split("\n")[]
| select(startswith("*"))
| split(" ")[1:][]
| select(. != "")
]

Mapping GitHub topics to a team relation

If your teams are represented as GitHub repository topics, use select with a list of known team slugs to map them:

team: >-
[
.topics[]
| select(. == "devops" or . == "platform" or . == "security")
] | first // null

Dynamic: match any topic that exists as a Port team identifier:

team: >-
.topics
| map(select(. != null and . != ""))
| first // null

Map to an array relation (multiple team owners):

teams: >-
[
.topics[]
| select(. == "devops" or . == "platform" or . == "security")
]
Match team identifiers exactly

Keep the team slug in the topic lowercase and hyphen-separated (for example platform-eng), and make sure it matches the Port team identifier exactly. Port identifiers are case-sensitive.

Null-safe navigation

Problem: .properties.description throws if properties is null.

Use // to provide a fallback:

.properties.description // ""

Use ? to suppress errors on a whole expression:

(.properties.tags[]? | select(. == "production")) // false

Check before mapping (if/else):

if .description != null and .description != ""
then .description
else "No description provided"
end

Safe array iteration (skip nulls):

[.items[]? | select(. != null) | .id]

Common pattern: safe string concat:

((.firstName // "") + " " + (.lastName // "")) | ltrimstr(" ") | rtrimstr(" ")

Filtering workflow runs to a specific repository

The GitHub Ocean integration injects .__repository (the repo name string) into each workflow run object. Use it in your selector to scope ingestion:

- kind: workflow-run
selector:
query: '.__repository == "my-repo-name"'

Filter to multiple repositories

  selector:
query: '[.__repository] | inside(["repo-a", "repo-b", "repo-c"])'

Important: For GitHub Ocean, ingestion is capped at the latest 100 workflow runs per workflow (see migration from GitHub app). Previously ingested runs can be removed on resync depending on your configuration. To retain history, add a ttl property on your workflow run blueprint and set it in your mapping so older runs are not deleted during resync.